<?php
/**
 * crcl/base
 *
 * Copyright 2013 CRCL - André Sabosch - info@crcl.de
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

namespace crcl\base\template;

interface ICompiler
{
  /**
   * Returns compiled template
   *
   * @return string
   */
  public function compile();
}

class Compiler extends \crcl\base\xml\sax\Builder implements ICompiler
{
  public function compile()
  {
    $this->parse();
    $sContent = '<?php $fExecuter = function(\crcl\base\template\Values $oValues) { ' .
      '$sContent =' . "\n" . '<<<BASE' . "\n";
    $sContent .= $this->getBuild();
    $sContent .= instructions\Base::endHeredoc() . 'return $sContent; } ?>';

    return $sContent;
  }

  //parser events (@see sax/Builder)
  public function onProcessingInstruction($sInstruction)
  {
    $sReturn = $sInstruction;
    if (\substr($sInstruction, 0, 6) == '<?base')
    {
      $aAttributes = $this->getAttributes($sInstruction);
      $bFound = \FALSE;

      if (isset($aAttributes['content']))
      {
        $oInstruction = Instruction::factory(Instruction::TYPE_CONTENT);
        $bFound = \TRUE;
      }

      if (isset($aAttributes['include']))
      {
        $oInstruction = Instruction::factory(Instruction::TYPE_INCLUDE);
        $bFound = \TRUE;
      }

      if (isset($aAttributes['foreach']))
      {
        $oInstruction = Instruction::factory(Instruction::TYPE_FOREACH);
        $bFound = \TRUE;
      }

      if (isset($aAttributes['if']))
      {
        $oInstruction = Instruction::factory(Instruction::TYPE_IF);
        $bFound = \TRUE;
      }

      if ($bFound)
      {
        $oInstruction->setAttributes($aAttributes);
        $oInstruction->verify();
        $oInstruction->execute();
        $sScope = $oInstruction->getScope();

        switch ($sScope)
        {
          case Instruction::SCOPE_CURRENT:
            $sReturn = $oInstruction->getOutput();
            break;
          case Instruction::SCOPE_NEXT:
            $sReturn = $oInstruction->getOutput();
            $this->startTrigger($oInstruction->getTriggeredOutput());
            break;
          case Instruction::SCOPE_ATTRIBUTE:
            $sReturn = $oInstruction->getOutput();
            $this->startTrigger($oInstruction->getTriggeredOutput(),
              $oInstruction->getTriggeredAttributes());
            break;
          default:
            throw new Exception('Unknown scope.',
              Exception::ERROR_COMPILER_UNKNOWNSCOPE,
              array('scope' => $sScope));
        }
      }
      else
      {
        throw new Exception('Invalid attribute',
          Exception::ERROR_COMPILER_INVALIDATTRIBUTE,
          array('aAttributes' => $aAttributes));
      }
    }
    parent::onProcessingInstruction($sReturn);
  }

  public function onElementStart($sTagName, $aAttributes, $bShort = \FALSE)
  {
    if ($sTagName == 'script')
    {
      $this->bScript = \TRUE;
    }

    if (($this->bTrigger) && (!\is_null($this->sTriggeredTag)))
    {
      if ($sTagName == $this->sTriggeredTag)
      {
        $this->iTriggeredTagNested++;
      }
    }
    if (($this->bTrigger) && (\is_null($this->sTriggeredTag)))
    {
      $this->sTriggeredTag = $sTagName;

      if ($this->bTriggerAttribute)
      {
        foreach ($this->aAttributesTrigger as $key => $value)
        {
          if (isset($aAttributes[$key]))
          {
            $aAttributes[$key] .= ' ' . $value;
          }
          else
          {
            $aAttributes[$key] = $value;
          }
        }
      }
    }

    $aAttributes = $this->compressAttributes($aAttributes);
    parent::onElementStart($sTagName, $aAttributes, $bShort);
  }

  public function onElementClose($sTagName)
  {
    if ($sTagName == 'script')
    {
      $this->bScript = \FALSE;
    }
    $bOutput = \FALSE;
    if (($this->bTrigger) && (!\is_null($this->sTriggeredTag)))
    {
      if ($this->sTriggeredTag == $sTagName)
      {
        if ($this->iTriggeredTagNested > 0)
        {
          $this->iTriggeredTagNested--;
        }
        else
        {
          $this->bTrigger = \FALSE;
          $this->sTriggeredTag = \NULL;
          $bOutput = \TRUE;
        }
      }
    }
    parent::onElementClose($sTagName);
    if ($bOutput)
    {
      $this->extendBuild($this->sOutputTrigger);
    }
  }

  public function onXmlDeclaration($sDeclaration)
  {
    //do nothing ;)
  }

  public function onElementData($sData)
  {
//    $sData = \trim($sData); INVALID compression
    if (!$this->bScript)
    {
      $sData = $this->convertNumericEntities($sData);
    }
    parent::onElementData($sData);
  }

  /** PRIVATE *************************************************************************************/

  private $bTrigger = \FALSE;
  private $bTriggerAttribute = \FALSE;
  private $sTriggeredTag = \NULL;
  private $iTriggeredTagNested = 0;
  private $sOutputTrigger = \NULL;
  private $aAttributesTrigger = array();
  private $bScript = \FALSE;

  private function getAttributes($sInstruction)
  {
    $sInstruction = \str_replace('?>', '', $sInstruction);
    $sInstruction = \str_replace('<?base', '', $sInstruction);
    $sInstruction = \trim($sInstruction);

    $bAttribute = \TRUE;
    $bValue = \FALSE;
    $sAttribute = '';
    $sValue = '';
    $aReturn = array();
    for ($i=0; $i<\strlen($sInstruction); $i++)
    {
      if ($bValue)
      {
        if ($sInstruction[$i] == '"') // end of value
        {
          $bValue = \FALSE;
          $aReturn[$sAttribute] = \trim($sValue);
          continue;
        }
        else
        {
          $sValue .= $sInstruction[$i];
        }
      }
      if (!$bValue && ($sInstruction[$i] == ' ')) //new attribute
      {
        $bAttribute = \TRUE;
        $bValue = \FALSE;
        $sAttribute = '';
        $sValue = '';
        continue;
      }
      if ($sInstruction[$i] == '=')
      {
        $bAttribute = \FALSE;
        continue;
      }
      if ($bAttribute)
      {
        $sAttribute .= $sInstruction[$i];
      }
      if (!$bAttribute)
      {
        if ($sInstruction[$i] == '"') //start of value
        {
          $bValue = \TRUE;
        }
        else
        {
          if (!$bValue) //missing "
          {
            throw new Exception('Missing quotation marks',
              Exception::ERROR_COMPILER_MISSING_QUOTATIONMARKS,
              array('sProcessingInstruction' => $sInstruction, 'sAttribute' => $sAttribute));
          }
        }
      }
    }
    return $aReturn;
  }

  private function startTrigger($sOutput, $aAttributes = array())
  {
    $this->bTrigger = \TRUE;
    $this->sOutputTrigger = $sOutput;
    if (!empty($aAttributes))
    {
      $this->aAttributesTrigger = $aAttributes;
      $this->bTriggerAttribute = \TRUE;
    }
  }

  private function stopTrigger()
  {
    $this->bTrigger = \FALSE;
    $this->bTriggerAttribute = \FALSE;
  }

  private function convertNumericEntities($sInput)
  {
    // get rid of existing entities else double-escape
    $sInput = \html_entity_decode(\stripslashes($sInput),\ENT_QUOTES, 'UTF-8');
    $aInput = \preg_split('/(?<!^)(?!$)/u', $sInput);  // return array of every multi-byte character
    $sOut = '';
    foreach ($aInput as $c)
    {
      if (\trim($c) !== '')
      {
        $o = \ord($c);
        if ((\strlen($c) > 1) || /* multi-byte [unicode] */
          ($o <32 || $o > 126) || /* <- control / latin weirdos -> */
          ($o >33 && $o < 40) ||/* quotes + ambersand */
          ($o >59 && $o < 63) /* html */)
        {
          // convert to numeric entity
          $c = \mb_encode_numericentity($c, array(0x0, 0xffff, 0, 0xffff), 'UTF-8');
        }
      }
      $sOut .= $c;
    }
    return $sOut;
  }

  private function compressAttributes($aAttributes)
  {
    foreach ($aAttributes as $key => $value)
    {
      if (empty($value))
      {
        unset($aAttributes[$key]);
      }
    }
    return $aAttributes;
  }
}
